Skip to content

feat: Add configurable automatic retries to the HTTP client#1659

Merged
awolfden merged 4 commits into
mainfrom
devin/1784227352-node-sdk-retries
Jul 16, 2026
Merged

feat: Add configurable automatic retries to the HTTP client#1659
awolfden merged 4 commits into
mainfrom
devin/1784227352-node-sdk-retries

Conversation

@awolfden

@awolfden awolfden commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Brings the Node SDK's automatic retry behavior to parity with the Kotlin, Go, Ruby, and Python SDKs.

Previously the Node SDK had retry machinery, but it was effectively dormant: retries only fired for two path prefixes (/vault/* and /audit_logs/events), never retried 429, ignored Retry-After, added no idempotency safety for retried writes, and weren't configurable. Every other WorkOS SDK retries all requests on transient failures with backoff + jitter.

This PR makes retries apply to all requests and configurable, while keeping default behavior transparent to existing callers (default is still 3 attempts, backoff + jitter unchanged).

What changed

  • Retries apply to every request, not just the two allowlisted path prefixes. The per-verb isPathRetryable(path) branch is removed; all verbs go through fetchRequestWithRetry.

  • Retryable statuses now include 429 and 503 (was [408, 500, 502, 504], now [408, 429, 500, 502, 503, 504]). Transport errors (TypeError) are still retried.

  • Retry-After is honored, capped at 60 seconds. When a retryable response carries a Retry-After header, the SDK sleeps for that duration instead of the computed backoff — capped at MAXIMUM_RETRY_AFTER_TIME_IN_MILLISECONDS = 60_000 so a large server-provided delay can't hang callers. Supports both delay-seconds (strict RFC 9110 integer) and HTTP-date forms:

    await this.sleep(attempt, HttpClient.parseRetryAfter(retryAfterHeader));
    // parseRetryAfter('120') -> 120000; parseRetryAfter('3600') -> 60000 (capped);
    // parseRetryAfter('<http-date>') -> ms until date (capped); else null (fall back to backoff)
  • Idempotency-Key auto-injection for retried POST requests. A generated key (retry-<uuid>) is attached once to POST requests that don't already have one, and reused across attempts so a retried write is not applied twice. A caller-provided key is always preserved. POST-only to match the Kotlin and Go SDKs and the API, which honors Idempotency-Key on create (POST) endpoints.

  • Backoff is now capped (MAXIMUM_SLEEP_TIME_IN_MILLISECONDS = 8_000) to avoid unbounded delays, matching Kotlin's maxDelayMs.

  • Configurable maxRetries, client-wide and per-request, 0 disables retries:

    const workos = new WorkOS({ apiKey, maxRetries: 5 });   // client-wide
    await workos.get('/path', { maxRetries: 0 });           // per-request override

    Wired through WorkOSOptions, the per-call option interfaces (GetOptions/PostOptions/PutOptions/PatchOptions), and RequestOptions. Per-request wins over client-wide, which falls back to the default of 3.

Notes

  • Because 408/429/5xx are now retried by default, a couple of existing error-mapping tests that asserted a single terminal 500/429 response now construct their client with maxRetries: 0 to isolate the mapping from retry behavior.

Test plan

  • Extended src/common/net/fetch-client.spec.ts: retries on non-allowlisted paths, 429, Retry-After honored (delay-seconds and HTTP-date, 60s cap, unparseable fallback), idempotency key generated + reused on POST (and not on PUT/PATCH), caller key preserved, network-error retry, DELETE retry, maxRetries: 0 disables, per-request override.
  • npm run lint, npm run typecheck, npm test (888 passing), and npm run build all pass locally.
  • A retries section was added to the README documenting the default behavior and maxRetries configuration.

Link to Devin session: https://app.devin.ai/sessions/45217aff3c0248e083c27dd69b391ca5
Requested by: @awolfden

Co-Authored-By: adam <adam@workos.com>
@awolfden
awolfden requested review from a team as code owners July 16, 2026 18:56
@awolfden
awolfden requested a review from marji-workos July 16, 2026 18:56
@awolfden awolfden self-assigned this Jul 16, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor
Original prompt from adam

SYSTEM:
=== BEGIN THREAD HISTORY (in #test-stuff) ===
Adam Wolfman (U028ZSWP2F8): 🧵 retries in Node SDK

<most_recent_message>
Adam Wolfman (U028ZSWP2F8): @Devin we had a customer ask if it were possible to add automatic retries into the WorkOS Node SDK like is included in some of the other SDK’s like WorkOS Kotlin SDK. Investigate how automatic retries are handled in some of the other WorkOS SDK’s, and how feasible it would be to add the same functionality into the Node SDK.
</most_recent_message>
=== END THREAD HISTORY ===

Thread URL: https://work-os.slack.com/archives/C0BH4BA32BS/p1784226561746409?thread_ts=1784226561.746409&amp;cid=C0BH4BA32BS

The latest message is the one right above that tagged you. The <most_recent_message> is the message that you should use to guide your goals + task for this session, and you should use the rest of the slack thread as context.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot changed the title Add configurable automatic retries to the HTTP client feat: Add configurable automatic retries to the HTTP client Jul 16, 2026
@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR brings the Node SDK's retry behavior to parity with the Kotlin, Go, Ruby, and Python SDKs: retries now apply to all requests (not just two path prefixes), 429/503 are retried, Retry-After is honored and capped, POST requests auto-receive an Idempotency-Key for retry safety, backoff is capped at 8 s, and maxRetries is configurable client-wide and per-request.

  • HttpClient gains parseRetryAfter, generateIdempotencyKey, a backoff cap, and the configurable MAX_RETRY_ATTEMPTS property; FetchHttpClient removes the isPathRetryable allowlist and routes every verb through fetchRequestWithRetry with per-call maxRetries support.
  • ParseError now carries rawHeaders so non-JSON error responses can surface a Retry-After header into the retry loop.
  • A comprehensive test suite covers 429, Retry-After (both forms), idempotency key generation/reuse/preservation, ParseError-based retry, maxRetries: 0, and per-request overrides.

Confidence Score: 5/5

Safe to merge — the retry machinery is well-tested, the backoff and jitter logic is correct, and the Retry-After and idempotency-key handling matches the behaviour of other WorkOS SDKs.

The implementation correctly handles the full retry lifecycle: retryable statuses, non-JSON error bodies, Retry-After header parsing (both delay-seconds and HTTP-date), idempotency key injection for POST, and configurable limits at both client and request level. The test suite is thorough and all edge cases are covered. The only gap is that per-request maxRetries cannot be passed to WorkOS.delete and deleteWithBody due to those methods existing signatures, but the client-wide setting still applies to deletes.

src/workos.ts — the delete and deleteWithBody methods do not propagate a per-request maxRetries override; all other files look correct.

Important Files Changed

Filename Overview
src/common/net/http-client.ts Core retry infrastructure: adds HttpClientOptions, parseRetryAfter, generateIdempotencyKey, backoff cap, and configurable MAX_RETRY_ATTEMPTS. Logic is correct.
src/common/net/fetch-client.ts Extends all HTTP verbs to use fetchRequestWithRetry universally; adds withIdempotencyKey, getRetryAfterMs, ParseError retry handling. Implementation is correct and well-handled.
src/workos.ts Propagates per-request maxRetries for post/get/put/patch, but delete and deleteWithBody lack per-request override support due to their signature not accepting an options object.
src/common/net/fetch-client.spec.ts Comprehensive retry test suite covering 429, Retry-After (delay-seconds and HTTP-date), idempotency key generation/reuse, ParseError retry, per-request override, and maxRetries:0 disabling.
src/common/exceptions/parse-error.ts Adds optional rawHeaders field so non-JSON error responses can carry the Retry-After header into the retry machinery.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant WorkOS
    participant FetchHttpClient
    participant API

    Caller->>WorkOS: get/post/put/patch(path, options)
    WorkOS->>FetchHttpClient: "verb(path, {maxRetries: opts.maxRetries})"
    FetchHttpClient->>FetchHttpClient: fetchRequestWithRetry(url, method, body, headers, maxRetries)
    Note over FetchHttpClient: resolve maxRetryAttempts = maxRetries ?? MAX_RETRY_ATTEMPTS
    loop Attempt 1 to N
        FetchHttpClient->>API: fetch(url)
        alt 2xx Success
            API-->>FetchHttpClient: 200 OK
            FetchHttpClient-->>WorkOS: HttpClientResponse
        else Retryable error
            API-->>FetchHttpClient: 5xx / 429 / timeout
            alt "attempt <= maxRetryAttempts"
                FetchHttpClient->>FetchHttpClient: sleep with Retry-After or backoff+jitter
            else exhausted
                FetchHttpClient-->>WorkOS: throw error
                WorkOS-->>Caller: throw WorkOSException
            end
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant WorkOS
    participant FetchHttpClient
    participant API

    Caller->>WorkOS: get/post/put/patch(path, options)
    WorkOS->>FetchHttpClient: "verb(path, {maxRetries: opts.maxRetries})"
    FetchHttpClient->>FetchHttpClient: fetchRequestWithRetry(url, method, body, headers, maxRetries)
    Note over FetchHttpClient: resolve maxRetryAttempts = maxRetries ?? MAX_RETRY_ATTEMPTS
    loop Attempt 1 to N
        FetchHttpClient->>API: fetch(url)
        alt 2xx Success
            API-->>FetchHttpClient: 200 OK
            FetchHttpClient-->>WorkOS: HttpClientResponse
        else Retryable error
            API-->>FetchHttpClient: 5xx / 429 / timeout
            alt "attempt <= maxRetryAttempts"
                FetchHttpClient->>FetchHttpClient: sleep with Retry-After or backoff+jitter
            else exhausted
                FetchHttpClient-->>WorkOS: throw error
                WorkOS-->>Caller: throw WorkOSException
            end
        end
    end
Loading

Reviews (5): Last reviewed commit: "fix(retries): scope auto idempotency key..." | Re-trigger Greptile

Co-Authored-By: adam <adam@workos.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai re: "Retry before parsing" (src/common/net/fetch-client.ts L216-236) — good catch, fixed in 2050d02.

shouldRetryRequest now also retries a ParseError whose rawStatus is in RETRY_STATUS_CODES, so a transient 500/502/503 returning a non-JSON body (e.g. an HTML error page from a proxy) is retried instead of failing on the first attempt. A malformed-JSON body on a non-retryable status (200, 400, 422, …) still surfaces as ParseError as before. Added a test — retries a retryable status with a non-JSON error body — covering a 503 HTML response followed by a success.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

The fix looks correct and well-reasoned. shouldRetryRequest now cleanly handles all three error categories:

  1. TypeError — network/transport errors (unchanged)
  2. HttpClientError with a retryable status — JSON error bodies (unchanged)
  3. ParseError with a retryable rawStatus — the new case: non-JSON bodies (e.g. HTML proxy error pages)

One minor observation: getRetryAfterMs only handles HttpClientError and returns null for ParseError, so a 503 HTML response that also carries a Retry-After header won't honor it — it'll fall back to computed backoff instead. That's probably fine in practice (a proxy returning an HTML error page is unlikely to also include a well-formed Retry-After), but worth being aware of if full compliance is ever needed. Not a blocker.

The retries a retryable status with a non-JSON error body test is a solid smoke test for the new branch. LGTM.

@devin-ai-integration

devin-ai-integration Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Update: I ended up fixing this rather than leaving it. As of 9c71ffa, ParseError carries the response headers (rawHeaders) and getRetryAfterMs reads them for the ParseError path too, so a retryable non-JSON 429/503 that includes a Retry-After header now honors the server-provided delay instead of falling back to backoff. Covered by the honors Retry-After on a retryable non-JSON error body test. Greptile's latest review is 5/5 with no outstanding issues.

Comment thread src/common/net/fetch-client.ts
devin-ai-integration Bot and others added 2 commits July 16, 2026 19:07
Co-Authored-By: adam <adam@workos.com>
- Restrict automatic Idempotency-Key injection to POST requests, matching
  the Kotlin and Go SDKs and the API, which honors the header on create
  (POST) endpoints. PUT/PATCH no longer get an auto-generated key.
- Cap server-provided Retry-After delays at 60 seconds so a large
  delay-seconds value or far-future HTTP-date can't hang callers.
- Parse Retry-After delay-seconds strictly per RFC 9110, rejecting exotic
  numeric forms like Infinity, hex, and exponent notation.
- Document automatic retries and maxRetries configuration in the README.
- Add tests: HTTP-date Retry-After, 60s cap, unparseable-value fallback,
  network-error retry, DELETE retry, and no auto key on PUT/PATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gjtorikian gjtorikian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i pushed a few changes, looks good to me!

@awolfden
awolfden merged commit 366b0cd into main Jul 16, 2026
8 checks passed
@awolfden
awolfden deleted the devin/1784227352-node-sdk-retries branch July 16, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants